리스트 key에 index를 쓰면 생기는 문제

리스트 key에 index를 쓰면 생기는 문제

한눈에 보기

React의 key는 형제 목록에서 데이터 항목의 정체성을 나타낸다. index를 key로 쓰면 “이 데이터”가 아니라 “이 위치”를 같은 컴포넌트로 취급한다. 삽입·삭제·정렬이 일어날 때 로컬 state, uncontrolled input, focus가 다른 데이터 행에 붙을 수 있다. key는 렌더 시점에 만들지 말고 데이터가 생성될 때 부여한 안정적인 ID를 사용한다.

목차

key는 경고를 없애는 속성이 아니다

목록을 처음 렌더하면 React가 key 경고를 보여 준다.

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoRow todo={todo} />
      ))}
    </ul>
  );
}

가장 빠르게 경고를 없애려고 index를 넣을 수 있다.

{todos.map((todo, index) => (
  <TodoRow key={index} todo={todo} />
))}

화면도 정상적으로 보인다. 하지만 key의 역할은 console 경고를 없애는 것이 아니라 이전 렌더의 각 자식과 다음 렌더의 자식을 대응시키는 것이다.

파일 이름이 없는 폴더에서 “첫 번째 파일”, “두 번째 파일”로만 문서를 구분한다고 생각해 보자. 맨 앞 문서를 삭제하면 원래 두 번째 문서가 첫 번째가 된다. 위치만으로는 같은 문서가 이동한 것인지 다른 문서로 바뀐 것인지 알 수 없다.

목록 key도 같은 역할을 한다.

key가 답하는 질문

“다음 렌더의 이 자식은 이전 렌더의 어떤 자식과 같은 정체성인가?”

React가 이전 목록과 다음 목록을 맞추는 방법

가상의 todo 목록이 다음과 같다고 해 보자.

const todos = [
  { id: "todo-a", title: "API 명세 작성" },
  { id: "todo-b", title: "테스트 추가" },
  { id: "todo-c", title: "배포 문서 정리" },
];

ID를 key로 렌더한다.

{todos.map((todo) => (
  <TodoRow key={todo.id} todo={todo} />
))}

목록 맨 앞에 새 todo가 추가된다.

이전 key: [todo-a, todo-b, todo-c]
다음 key: [todo-x, todo-a, todo-b, todo-c]

React는 todo-x를 새로 만들고 기존 세 항목은 같은 key를 찾아 이동·재사용할 수 있다.

index가 key라면 다음처럼 보인다.

이전 key: [0, 1, 2]
다음 key: [0, 1, 2, 3]

React 관점에서는 key 0, 1, 2가 그대로다. key 0에 연결된 이전 컴포넌트는 원래 todo-a였지만 이제 props로 todo-x를 받는다. 각 행의 local state는 컴포넌트 정체성에 남아 있으므로 새로운 데이터와 섞일 수 있다.

flowchart LR
    subgraph Before[index key 삽입 전]
      A0["key 0
todo-a
state A"] A1["key 1
todo-b
state B"] A2["key 2
todo-c
state C"] end subgraph After[index key 삽입 후] B0["key 0
todo-x
state A"] B1["key 1
todo-a
state B"] B2["key 2
todo-b
state C"] B3["key 3
todo-c
새 state"] end A0 --> B0 A1 --> B1 A2 --> B2

DOM 텍스트는 새 props로 바뀌어 겉으로 정상처럼 보일 수 있다. 문제는 컴포넌트 안의 state와 DOM이 기억하는 값이다.

index key 버그를 입력 목록으로 재현하기

각 todo 행에 수정용 uncontrolled input이 있다고 하자.

type TodoRowProps = {
  todo: Todo;
};

function TodoRow({ todo }: TodoRowProps) {
  return (
    <li>
      <span>{todo.id}</span>
      <input
        aria-label={`${todo.title} 수정`}
        defaultValue={todo.title}
      />
    </li>
  );
}

부모는 index key를 사용한다.

function TodoEditor() {
  const [todos, setTodos] = useState(initialTodos);

  function prependTodo() {
    setTodos((current) => [
      { id: "todo-x", title: "긴급 버그 수정" },
      ...current,
    ]);
  }

  return (
    <>
      <button type="button" onClick={prependTodo}>
        맨 앞에 추가
      </button>
      <ul>
        {todos.map((todo, index) => (
          <TodoRow key={index} todo={todo} />
        ))}
      </ul>
    </>
  );
}

재현 순서:

  1. “테스트 추가” input을 “테스트 보강”으로 수정한다.
  2. 맨 앞에 새 todo를 추가한다.
  3. 각 행의 label과 input 값을 비교한다.

DOM input은 uncontrolled이므로 기존 노드의 현재 값을 보존한다. key 1의 노드가 재사용되지만 이제 props는 todo-a를 가리킨다. 사용자가 입력한 “테스트 보강”이 “API 명세 작성” 행에 붙을 수 있다.

ID key로 바꾸면 컴포넌트와 DOM이 todo 데이터의 이동을 따라간다.

{todos.map((todo) => (
  <TodoRow key={todo.id} todo={todo} />
))}

controlled input도 안전하다고 단정할 수 없다. 값 자체를 부모 데이터에서 받으면 화면 값은 맞을 수 있지만, 행 내부의 focus, validation state, expanded state, animation state가 다른 데이터에 붙을 수 있다.

function TodoRow({ todo }: TodoRowProps) {
  const [validationVisible, setValidationVisible] = useState(false);

  return (
    <li>
      <TodoInput todo={todo} />
      <button onClick={() => setValidationVisible(true)}>
        검증 보기
      </button>
      {validationVisible && <TodoValidation todo={todo} />}
    </li>
  );
}

index key에서는 어떤 행에서 연 validation UI가 삽입 후 다른 todo에 나타날 수 있다.

삭제와 정렬에서도 같은 문제가 생긴다

중간 항목 삭제

삭제 전:
key 0 → todo-a
key 1 → todo-b
key 2 → todo-c

todo-a 삭제 후:
key 0 → todo-b  ← todo-a의 state 재사용
key 1 → todo-c  ← todo-b의 state 재사용

첫 항목의 audio player 재생 위치가 다음 항목에 붙거나 checkbox의 local state가 한 칸씩 이동할 수 있다.

정렬 변경

상품 가격순 정렬을 바꾸면 위치가 완전히 달라진다.

const sortedProducts = [...products].sort((left, right) =>
  sortDirection === "asc"
    ? left.price - right.price
    : right.price - left.price,
);

index key는 “첫 번째 상품 컴포넌트”를 유지하고 props만 다른 상품으로 바꾼다. 상품별 이미지 loading state나 수량 input이 잘못 대응할 수 있다.

필터링

완료 항목 숨기기도 목록 위치를 바꾼다.

const visibleTodos = showCompleted
  ? todos
  : todos.filter((todo) => !todo.completed);

필터가 토글될 때 index가 다시 계산된다. 목록 자체가 원본 배열 순서를 유지하더라도 화면에 보이는 배열의 구성은 변한다.

정렬하지 않으니 안전하다는 착각

삽입, 삭제, filtering, pagination 경계 변경도 index와 데이터의 대응을 바꾼다.

index key를 사용해도 되는 제한적인 경우

다음 조건을 모두 만족하는 정적인 목록에서는 index key가 실질적인 문제를 만들지 않을 수 있다.

  1. 항목이 추가되거나 삭제되지 않는다.
  2. 순서가 바뀌지 않는다.
  3. filtering되지 않는다.
  4. 각 항목에 local state나 사용자 입력이 없다.
  5. 데이터에 안정적인 ID를 만들 수 없다.

예를 들어 빌드 시 고정된 문장 조각을 단순 렌더하는 경우다.

const lines = [
  "서비스 점검 시간은 02:00~03:00입니다.",
  "점검 중 일부 기능을 사용할 수 없습니다.",
];

function Notice() {
  return (
    <ul>
      {lines.map((line, index) => (
        <li key={index}>{line}</li>
      ))}
    </ul>
  );
}

다만 line 자체가 목록 안에서 유일하고 변하지 않는다면 문자열을 key로 쓸 수도 있다.

{lines.map((line) => (
  <li key={line}>{line}</li>
))}

요구사항은 바뀐다. 현재 정적이라는 이유로 index를 선택했다가 나중에 편집·정렬 기능이 추가되면 key 설계를 다시 검토해야 한다. 데이터 모델에 ID가 없다면 그 사실 자체가 정체성 정의가 부족하다는 신호일 수 있다.

좋은 key가 갖춰야 할 조건

좋은 key는 다음 세 조건을 만족한다.

1. 형제 사이에서 유일하다

key는 앱 전체에서 전역으로 유일할 필요는 없다. 같은 부모의 형제 목록 안에서 구분되면 된다.

function TwoLists() {
  return (
    <>
      <ActiveTodos todos={todos} />
      <ArchivedTodos todos={todos} />
    </>
  );
}

각 목록에서 같은 todo ID를 key로 써도 부모 목록이 다르므로 괜찮다.

2. 렌더 사이에 안정적이다

데이터 항목이 같은 동안 key도 같아야 한다. 제목처럼 사용자가 수정할 수 있는 값을 key로 쓰면 제목 변경 때 컴포넌트가 재마운트된다.

<TodoRow key={todo.title} todo={todo} />

제목 중복도 가능하므로 유일성도 부족하다.

3. 데이터 정체성에서 나온다

DB ID, event ID, 생성 시 부여한 UUID처럼 항목 자체를 식별하는 값을 사용한다.

<TodoRow key={todo.id} todo={todo} />

목록 위치나 렌더 횟수에서 key를 만들지 않는다.

key는 변경 감지용 version이 아니다

객체 내용이 바뀔 때 key까지 바꿀 필요는 없다. 같은 항목의 props가 변경된 것은 재렌더로 처리한다. key 변경은 다른 정체성으로 교체해 state를 폐기한다는 의미다.

랜덤 key와 useId가 대안이 아닌 이유

index 문제를 피하려고 렌더 중 UUID를 만들면 더 큰 문제가 생긴다.

{todos.map((todo) => (
  <TodoRow key={crypto.randomUUID()} todo={todo} />
))}

모든 렌더에서 key가 달라져 React는 이전 항목과 하나도 대응시키지 못한다.

UUID를 쓸 수 없는 것이 아니라 데이터 생성 시 한 번 만들어 저장해야 한다.

function addTodo(title: string) {
  setTodos((current) => [
    ...current,
    {
      id: crypto.randomUUID(),
      title,
      completed: false,
    },
  ]);
}

useId도 목록 key 생성용이 아니다. useId는 label의 htmlFor, aria-describedby처럼 접근성 속성을 연결하는 안정적인 ID를 만드는 Hook이다.

function PasswordField() {
  const hintId = useId();

  return (
    <>
      <input type="password" aria-describedby={hintId} />
      <p id={hintId}>8자 이상 입력하세요.</p>
    </>
  );
}

Hook은 loop 안에서 호출할 수도 없고, 목록 key는 데이터에서 와야 한다.

서버 ID가 아직 없는 optimistic item 처리

새 댓글을 서버 응답 전에 화면에 표시하려면 아직 DB ID가 없다.

잘못된 방식은 임시 key를 사용하다 서버 응답 후 key를 교체하는 것이다.

// 처음
{ key: "temp-0", text: "새 댓글" }

// 응답 후
{ key: "comment-9821", text: "새 댓글" }

key가 바뀌면 React는 다른 컴포넌트로 보고 state를 초기화한다. 사용자가 전송 후 추가 편집 중이었다면 focus와 local state가 사라질 수 있다.

클라이언트에서 생성한 안정적인 ID를 항목의 UI identity로 유지하고 서버 ID를 별도 필드로 둔다.

type OptimisticComment = {
  clientId: string;
  serverId: string | null;
  text: string;
  status: "sending" | "sent" | "failed";
};
function createOptimisticComment(text: string): OptimisticComment {
  return {
    clientId: crypto.randomUUID(),
    serverId: null,
    text,
    status: "sending",
  };
}

{comments.map((comment) => (
  <CommentRow
    key={comment.clientId}
    comment={comment}
  />
))}

서버 응답이 오면 같은 clientId 항목에 serverId와 status를 갱신한다.

서버가 client-generated ID를 idempotency key나 실제 PK로 받아 줄 수 있다면 모델이 더 단순해질 수 있다. 보안과 충돌 정책은 별도로 검토한다.

복합 key를 만들 때 주의할 점

자연스러운 단일 ID가 없으면 여러 필드로 복합 key를 만들 수 있다.

<PriceCell
  key={`${price.productId}:${price.currency}`}
  price={price}
/>

구분자 없이 단순 연결하면 충돌할 수 있다.

`${leftId}${rightId}`

1 + 2312 + 3이 모두 "123"이 된다. 명확한 구분이나 길이 인코딩을 사용한다.

복합 key 필드가 수정 가능한 값이면 정체성도 바뀌는지 판단해야 한다. 상품의 통화가 바뀌면 정말 다른 PriceCell로 초기화해야 하는가, 같은 가격 항목의 속성 변경인가?

시간 stamp를 key에 섞어 강제 갱신하는 패턴은 피한다.

<Chart key={`${chartId}:${Date.now()}`} data={data} />

데이터가 바뀔 때 일반 props update로 처리하고, 정말 내부 state를 폐기해야 하는 의미 있는 version만 key에 포함한다.

key는 props로 전달되지 않는다

key는 React가 reconciliation에 사용하는 특별한 값이라 자식 props에서 읽을 수 없다.

function TodoRow(props: TodoRowProps) {
  console.log(props.key);
}

컴포넌트에도 ID가 필요하면 별도 prop으로 전달한다.

<TodoRow
  key={todo.id}
  todoId={todo.id}
  todo={todo}
/>

key와 ID가 같은 값을 쓰더라도 역할은 다르다.

Fragment와 중첩 목록의 key

한 항목이 여러 sibling element를 반환하지만 wrapper DOM을 추가하고 싶지 않다면 명시적 Fragment에 key를 준다.

import { Fragment } from "react";

function DefinitionList({ terms }: { terms: Term[] }) {
  return (
    <dl>
      {terms.map((term) => (
        <Fragment key={term.id}>
          <dt>{term.name}</dt>
          <dd>{term.description}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

짧은 <>...</> syntax에는 key를 전달할 수 없다.

중첩 목록에서는 각 수준의 형제에게 해당 범위의 key를 준다.

{categories.map((category) => (
  <section key={category.id}>
    <h2>{category.name}</h2>
    <ul>
      {category.products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  </section>
))}

상품 ID가 카테고리마다만 유일해도 각 <ul>이 별도 형제 범위라면 충분하다. 상품을 카테고리 사이에서 이동시키면서 local state를 보존해야 한다면 부모 구조까지 포함해 상태 소유 설계를 검토해야 한다. key만으로 다른 부모 사이 state 이동을 보장하지 않는다.

key를 의도적으로 바꿔 state를 초기화하기

key 변경은 항상 버그가 아니다. 다른 entity를 편집할 때 이전 draft를 버리는 의미로 사용할 수 있다.

function CustomerPage({ customerId }: { customerId: string }) {
  return (
    <CustomerEditor
      key={customerId}
      customerId={customerId}
    />
  );
}

고객이 바뀌면 CustomerEditor subtree가 재생성되고 local form state와 Effect가 초기화된다.

사용자가 직접 누르는 reset button에도 version key를 쓸 수 있다.

function ResettableForm() {
  const [version, setVersion] = useState(0);

  return (
    <>
      <button
        type="button"
        onClick={() => setVersion((current) => current + 1)}
      >
        전체 초기화
      </button>
      <ProfileForm key={version} />
    </>
  );
}

key 초기화는 subtree의 모든 state, DOM, ref, Effect를 폐기한다. 일부 field만 초기화하려면 state action이나 form API가 더 적절하다.

강제 재렌더 도구가 아니다

key를 바꾸면 단순 update가 아니라 unmount와 mount가 일어난다. stale props 문제를 숨기거나 데이터를 다시 fetch하기 위한 우회로로 남용하지 않는다.

테스트와 리뷰 체크리스트

key 버그는 정적 snapshot보다 사용자 상호작용 후 목록 변경으로 테스트해야 한다.

it("맨 앞에 항목을 추가해도 기존 입력은 같은 todo에 남는다", async () => {
  const user = userEvent.setup();
  render(<TodoEditor />);

  const target = screen.getByLabelText("테스트 추가 수정");
  await user.clear(target);
  await user.type(target, "테스트 보강");

  await user.click(
    screen.getByRole("button", { name: "맨 앞에 추가" }),
  );

  expect(
    screen.getByLabelText("테스트 추가 수정"),
  ).toHaveValue("테스트 보강");
});

정렬 테스트에서는 focus와 local state도 확인한다.

await user.click(screen.getByRole("button", { name: "가격 내림차순" }));

expect(screen.getByLabelText("상품 A 수량")).toHaveValue(3);
expect(screen.getByLabelText("상품 A 수량")).toHaveFocus();

리뷰 체크리스트:

정리

React list의 key는 항목 위치가 아니라 데이터 정체성을 표현해야 한다. index key는 목록의 첫 번째, 두 번째 위치를 같은 컴포넌트로 유지하므로 데이터가 이동하면 기존 state와 DOM이 다른 항목에 붙을 수 있다.

좋은 key는 “현재 몇 번째인가”가 아니라 “이 항목은 무엇인가”에 답한다.

관련 노트와 참고 자료